//
//  swift-code-generation.swift
//  json2swift
//
//  Created by Joshua Smith on 10/14/16.
//  Copyright © 2016 iJoshSmith. All rights reserved.
//

import Foundation

struct SwiftCodeGenerator {
    /// This method is used when only one Swift file is being generated.
    static func generateCodeWithJSONUtilities(for swiftStruct: SwiftStruct) -> String {
        return [
            preamble,
            "//",
            "// MARK: - Data Model",
            "//",
            swiftStruct.toSwiftCode(),
            "",
            "//",
            "// MARK: - JSON Utilities",
            "//",
            jsonUtilitiesTemplate,
            ""].joined(separator: "\n")
    }
    
    /// This method is used when multiple Swift files are being generated.
    static func generateCode(for swiftStruct: SwiftStruct) -> String {
        return [
            preamble,
            swiftStruct.toSwiftCode(),
            ""].joined(separator: "\n")
    }
    
    /// This method is used to only create the JSON utility code once when multiple Swift files are being generated.
    static func generateJSONUtilities() -> String {
        return [
            preamble,
            jsonUtilitiesTemplate,
            ""].joined(separator: "\n")
    }
    
    private static let preamble = [
        "// This file was generated by json2swift. https://github.com/ijoshsmith/json2swift",
        "",
        "import Foundation",
        ""].joined(separator: "\n")
}


// MARK: - Implementation

typealias SwiftCode = String
typealias LineOfCode = SwiftCode

fileprivate struct Indentation {
    private let chars: String
    private let level: Int
    private let value: String
    
    init(chars: String, level: Int = 0) {
        precondition(level >= 0)
        self.chars = chars
        self.level = level
        self.value = String(repeating: chars, count: level)
    }
    
    func apply(toLineOfCode lineOfCode: LineOfCode) -> LineOfCode {
        return value + lineOfCode
    }
    
    func apply(toFirstLine firstLine: LineOfCode,
               nestedLines generateNestedLines: (Indentation) -> [LineOfCode],
               andLastLine lastLine: LineOfCode) -> [LineOfCode] {
        let first  = apply(toLineOfCode: firstLine)
        let middle = generateNestedLines(self.increased())
        let last   = apply(toLineOfCode: lastLine)
        return [first] + middle + [last]
    }
    
    private func increased() -> Indentation {
        return Indentation(chars: chars, level: level + 1)
    }
}

fileprivate extension SwiftStruct {
    func toSwiftCode(indentedBy indentChars: String = "    ") -> SwiftCode {
        let indentation = Indentation(chars: indentChars)
        let linesOfCode = toLinesOfCode(at: indentation)
        return linesOfCode.joined(separator: "\n")
    }
    
    private func toLinesOfCode(at indentation: Indentation) -> [LineOfCode] {
        return indentation.apply(
            toFirstLine: "struct \(name): CreatableFromJSON {",
            nestedLines:      linesOfCodeForMembers(at:),
            andLastLine: "}")
    }
    
    private func linesOfCodeForMembers(at indentation: Indentation) -> [LineOfCode] {
        return linesOfCodeForProperties(at: indentation)
            + initializer.toLinesOfCode(at: indentation)
            + failableInitializer.toLinesOfCode(at: indentation)
            + linesOfCodeForNestedStructs(at: indentation)
    }
    
    private func linesOfCodeForProperties(at indentation: Indentation) -> [LineOfCode] {
        return sortedProperties.map { property in
            let propertyCode = property.toLineOfCode()
            return indentation.apply(toLineOfCode: propertyCode)
        }
    }
    
    private var sortedProperties: [SwiftProperty] {
        return properties.sorted { (lhs, rhs) -> Bool in
            return lhs.name.compare(rhs.name) == .orderedAscending
        }
    }
    
    private func linesOfCodeForNestedStructs(at indentation: Indentation) -> [LineOfCode] {
        return sortedNestedStructs.flatMap { $0.toLinesOfCode(at: indentation) }
    }
    
    private var sortedNestedStructs: [SwiftStruct] {
        return nestedStructs.sorted(by: { (lhs, rhs) -> Bool in
            return lhs.name.compare(rhs.name) == .orderedAscending
        })
    }
}

fileprivate extension SwiftType {
    func toSwiftCode() -> SwiftCode {
        return isOptional ? name + "?" : name
    }
}

fileprivate extension SwiftProperty {
    func toLineOfCode() -> LineOfCode {
        return "let \(name): \(type.toSwiftCode())"
    }
}

fileprivate extension SwiftParameter {
    func toSwiftCode() -> SwiftCode {
        return "\(name): \(type.toSwiftCode())"
    }
}

fileprivate extension SwiftInitializer {
    func toLinesOfCode(at indentation: Indentation) -> [LineOfCode] {
        return indentation.apply(
            toFirstLine: "init(\(parameterList)) {",
            nestedLines:      linesOfCodeForPropertyAssignments(at:),
            andLastLine: "}")
    }
    
    private var parameterList: SwiftCode {
        return sortedParameters
            .map { $0.toSwiftCode() }
            .joined(separator: ", ")
    }
    
    private func linesOfCodeForPropertyAssignments(at indentation: Indentation) -> [LineOfCode] {
        return sortedParameters
            .map { "self.\($0.name) = \($0.name)" }
            .map(indentation.apply(toLineOfCode:))
    }
    
    private var sortedParameters: [SwiftParameter] {
        return parameters.sorted { (lhs, rhs) -> Bool in
            return lhs.name.compare(rhs.name) == .orderedAscending
        }
    }
}

fileprivate extension SwiftFailableInitializer {
    func toLinesOfCode(at indentation: Indentation) -> [LineOfCode] {
        return indentation.apply(
            toFirstLine: "init?(json: [String: Any]) {",
            nestedLines:      linesOfCodeInMethodBody(at:),
            andLastLine: "}")
    }
    
    private func linesOfCodeInMethodBody(at indentation: Indentation) -> [LineOfCode] {
        let linesOfCode = linesOfCodeForTransformations + [lineOfCodeForCallingInitializer]
        return linesOfCode.map(indentation.apply(toLineOfCode:))
    }
    
    private var linesOfCodeForTransformations: [LineOfCode] {
        let requiredTransformationLines = sortedRequiredTransformations.map { $0.guardedLetStatement }
        let optionalTransformationLines = sortedOptionalTransformations.map { $0.letStatement }
        return (requiredTransformationLines + optionalTransformationLines)
    }
    
    private var lineOfCodeForCallingInitializer: LineOfCode {
        let sortedPropertyNames = (requiredTransformations + optionalTransformations).map { $0.propertyName }.sorted()
        let labeledArguments = sortedPropertyNames.map { $0 + ": " + $0 }
        let argumentList = labeledArguments.joined(separator: ", ")
        return "self.init(" + argumentList + ")"
    }
    
    private var sortedRequiredTransformations: [TransformationFromJSON] {
        return sort(transformations: requiredTransformations)
    }
    
    private var sortedOptionalTransformations: [TransformationFromJSON] {
        return sort(transformations: optionalTransformations)
    }
    
    private func sort(transformations: [TransformationFromJSON]) -> [TransformationFromJSON] {
        return transformations.sorted { (lhs, rhs) -> Bool in
            return lhs.propertyName.compare(rhs.propertyName) == .orderedAscending
        }
    }
}

// Internal for unit test access.
internal extension TransformationFromJSON {
    var propertyName: String {
        switch self {
        case let .toCustomStruct(_, propertyName, _):           return propertyName
        case let .toPrimitiveValue(_, propertyName, _):         return propertyName
        case let .toCustomStructArray(_, propertyName, _, _):   return propertyName
        case let .toPrimitiveValueArray(_, propertyName, _, _): return propertyName
        }
    }
    
    var guardedLetStatement: LineOfCode {
        return "guard \(letStatement) else { return nil }"
    }
    
    var letStatement: LineOfCode {
        switch self {
        case let .toCustomStruct(       attributeName, propertyName, type): return TransformationFromJSON.letStatementForCustomStruct(  attributeName, propertyName, type)
        case let .toPrimitiveValue(     attributeName, propertyName, type): return TransformationFromJSON.letStatementForPrimitiveValue(attributeName, propertyName, type)
        case let .toCustomStructArray(  attributeName, propertyName, elementType, hasOptionalElements): return TransformationFromJSON.letStatementForCustomStructArray(  attributeName, propertyName, elementType, hasOptionalElements)
        case let .toPrimitiveValueArray(attributeName, propertyName, elementType, hasOptionalElements): return TransformationFromJSON.letStatementForPrimitiveValueArray(attributeName, propertyName, elementType, hasOptionalElements)
        }
    }
    
    private static func letStatementForCustomStruct(_ attributeName: String, _ propertyName: String, _ type: SwiftStruct) -> LineOfCode {
        return "let \(propertyName) = \(type.name)(json: json, key: \"\(attributeName)\")"
    }
    
    private static func letStatementForPrimitiveValue(_ attributeName: String, _ propertyName: String, _ type: SwiftPrimitiveValueType) -> LineOfCode {
        switch type {
        case .any:                 return "let \(propertyName) = json[\"\(attributeName)\"] as? Any"
        case .emptyArray:          return "let \(propertyName) = json[\"\(attributeName)\"] as? [Any?]"
        case .bool, .int, .string: return "let \(propertyName) = json[\"\(attributeName)\"] as? \(type.name)"
        case .double:              return "let \(propertyName) = Double(json: json, key: \"\(attributeName)\")" // Allows an integer to be interpreted as a double.
        case .url:                 return "let \(propertyName) = URL(json: json, key: \"\(attributeName)\")"
        case .date(let format):    return "let \(propertyName) = Date(json: json, key: \"\(attributeName)\", format: \"\(format)\")"
        }
    }
    
    private static func letStatementForCustomStructArray(_ attributeName: String, _ propertyName: String, _ elementType: SwiftStruct, _ hasOptionalElements: Bool) -> LineOfCode {
        return hasOptionalElements
            ? "let \(propertyName) = \(elementType.name).createOptionalInstances(from: json, arrayKey: \"\(attributeName)\")"
            : "let \(propertyName) = \(elementType.name).createRequiredInstances(from: json, arrayKey: \"\(attributeName)\")"
    }
    
    private static func letStatementForPrimitiveValueArray(_ attributeName: String, _ propertyName: String, _ elementType: SwiftPrimitiveValueType, _ hasOptionalElements: Bool) -> LineOfCode {
        return hasOptionalElements
            ? letStatementForArrayOfOptionalPrimitiveValues(attributeName, propertyName, elementType)
            : letStatementForArrayOfRequiredPrimitiveValues(attributeName, propertyName, elementType)
    }
    
    private static func letStatementForArrayOfOptionalPrimitiveValues(_ attributeName: String, _ propertyName: String, _ elementType: SwiftPrimitiveValueType) -> LineOfCode {
        switch elementType {
        case .any, .bool, .int, .string, .emptyArray: return "let \(propertyName) = (json[\"\(attributeName)\"] as? [Any]).map({ $0.toOptionalValueArray() as [\(elementType.name)?] })"
        case .date(let format):                       return "let \(propertyName) = (json[\"\(attributeName)\"] as? [Any]).map({ $0.toOptionalDateArray(withFormat: \"\(format)\") })"
        case .double:                                 return "let \(propertyName) = (json[\"\(attributeName)\"] as? [Any]).map({ $0.toOptionalDoubleArray() })"
        case .url:                                    return "let \(propertyName) = (json[\"\(attributeName)\"] as? [Any]).map({ $0.toOptionalURLArray() })"
        }
    }
    
    private static func letStatementForArrayOfRequiredPrimitiveValues(_ attributeName: String, _ propertyName: String, _ elementType: SwiftPrimitiveValueType) -> LineOfCode {
        switch elementType {
        case .any:                 return "let \(propertyName) = json[\"\(attributeName)\"] as? [Any?]" // Any is treated as optional.
        case .emptyArray:          return "let \(propertyName) = json[\"\(attributeName)\"] as? [[Any?]]"
        case .bool, .int, .string: return "let \(propertyName) = json[\"\(attributeName)\"] as? [\(elementType.name)]"
        case .date(let format):    return "let \(propertyName) = (json[\"\(attributeName)\"] as? [String]).flatMap({ $0.toDateArray(withFormat: \"\(format)\") })"
        case .double:              return "let \(propertyName) = (json[\"\(attributeName)\"] as? [NSNumber]).map({ $0.toDoubleArray() })"
        case .url:                 return "let \(propertyName) = (json[\"\(attributeName)\"] as? [String]).flatMap({ $0.toURLArray() })"
        }
    }
}

fileprivate extension SwiftPrimitiveValueType {
    var name: String {
        switch self {
        case .any:        return "Any"
        case .bool:       return "Bool"
        case .date:       return "Date"
        case .double:     return "Double"
        case .emptyArray: return "[Any?]"
        case .int:        return "Int"
        case .string:     return "String"
        case .url:        return "URL"
        }
    }
}
